# This file is distributed under New Relic's license terms.
# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details.
# frozen_string_literal: true

require_relative 'app/models/models'

class ActiveRecordInstrumentationTest < Minitest::Test
  include MultiverseHelpers
  setup_and_teardown_agent

  def after_setup
    super
    NewRelic::Agent.drop_buffered_data
  end

  def test_metrics_for_calculation_methods
    in_web_transaction do
      Order.count
      Order.average(:id)
      Order.minimum(:id)
      Order.maximum(:id)
      Order.sum(:id)
    end

    if active_record_version_at_least?(7)
      assert_activerecord_metrics(Order, 'find')
    else
      assert_activerecord_metrics(Order, 'select', call_count: '>= 5')
    end
  end

  def test_metrics_for_pluck
    in_web_transaction do
      Order.pluck(:id)
    end

    if active_record_version_at_least?(7)
      assert_activerecord_metrics(Order, 'pluck')
    else
      assert_activerecord_metrics(Order, 'select')
    end
  end

  def test_metrics_for_ids
    in_web_transaction do
      Order.ids
    end

    if active_record_version_at_least?(7.1)
      assert_activerecord_metrics(Order, 'ids')
    elsif active_record_version_at_least?(7)
      assert_activerecord_metrics(Order, 'pluck')
    else
      assert_activerecord_metrics(Order, 'select')
    end
  end

  def test_metrics_for_create
    in_web_transaction do
      Order.create(:name => 'bob')
    end

    assert_activerecord_metrics(Order, 'create')
  end

  def test_metrics_for_create_via_association
    in_web_transaction do
      order = Order.create(:name => 'bob')
      order.shipments.create
      order.shipments.to_a
    end

    assert_activerecord_metrics(Order, 'create')
  end

  def test_metrics_for_find
    in_web_transaction do
      Order.where(:name => 'foo').load
    end

    assert_activerecord_metrics(Order, 'find')
  end

  def test_metrics_for_find_via_association
    in_web_transaction do
      order = Order.create(:name => 'bob')
      order.shipments.create
      order.shipments.to_a
    end

    assert_activerecord_metrics(Shipment, 'find')
  end

  def test_metrics_for_find_all
    in_web_transaction do
      Order.all.load
    end

    assert_activerecord_metrics(Order, 'find')
  end

  def test_metrics_for_find_via_named_scope
    Order.class_eval do
      scope(:jeffs, lambda { where(:name => 'Jeff') })
    end

    in_web_transaction do
      Order.jeffs.load
    end

    assert_activerecord_metrics(Order, 'find')
  end

  def test_metrics_for_exists
    in_web_transaction do
      Order.exists?(['name=?', 'jeff'])
    end

    if active_record_version_at_least?(6)
      assert_activerecord_metrics(Order, 'exists?')
    else
      assert_activerecord_metrics(Order, 'find')
    end
  end

  def test_metrics_for_update_all
    order1, order2 = nil

    in_web_transaction do
      order1 = Order.create(:name => 'foo')
      order2 = Order.create(:name => 'foo')
      Order.update_all(:name => 'zing')
    end

    assert_activerecord_metrics(Order, 'update')

    assert_equal('zing', order1.reload.name)
    assert_equal('zing', order2.reload.name)
  end

  def test_metrics_for_delete_all
    in_web_transaction do
      Order.create(:name => 'foo')
      Order.create(:name => 'foo')
      Order.delete_all
    end

    assert_activerecord_metrics(Order, 'delete')
  end

  def test_metrics_for_relation_delete
    in_web_transaction do
      order = Order.create(:name => 'lava')
      Order.delete(order.id)
    end

    assert_activerecord_metrics(Order, 'delete')
  end

  def test_metrics_for_delete
    in_web_transaction do
      order = Order.create('name' => 'burt')
      order.delete
    end

    if active_record_version_at_least?(7)
      assert_activerecord_metrics(Order, 'destroy')
    else
      assert_activerecord_metrics(Order, 'delete')
    end
  end

  def test_metrics_for_touch
    in_web_transaction do
      order = Order.create('name' => 'wendy')
      order.touch
    end

    assert_activerecord_metrics(Order, 'update')
  end

  def test_metrics_for_relation_update
    in_web_transaction do
      order = Order.create(:name => 'foo')
      Order.update(order.id, :name => 'qux')
    end

    assert_activerecord_metrics(Order, 'update')
  end

  def test_create_via_association_equal
    in_web_transaction do
      u = User.create(:name => 'thom yorke')
      groups = [
        Group.new(:name => 'radiohead'),
        Group.new(:name => 'atoms for peace')
      ]
      u.groups = groups
    end

    assert_activerecord_metrics(Group, 'create')
  end

  # depends on gem versions in play
  def postgresql_not_unique_error_class
    /(PG::UniqueViolation)|(ActiveRecord::RecordNotUnique)|(ActiveRecord::JDBCError)/
  end

  def test_noticed_error_at_segment_and_txn_when_violating_unique_constraints
    expected_error_class = postgresql_not_unique_error_class
    txn = nil
    begin
      in_web_transaction do |web_txn|
        txn = web_txn
        u = User.create(:name => 'thom yorke')
        u2 = User.create(u.attributes)
      end
    rescue StandardError => e
      # NOP -- allowing span and transaction to notice error
    end

    assert_segment_noticed_error txn, /create|insert/i, expected_error_class, /duplicate key/i
    assert_transaction_noticed_error txn, expected_error_class
  end

  def test_noticed_error_only_at_segment_when_violating_unique_constraints
    expected_error_class = postgresql_not_unique_error_class
    txn = nil
    in_web_transaction do |web_txn|
      begin
        txn = web_txn
        u = User.create(:name => 'thom yorke')
        u2 = User.create(u.attributes)
      rescue StandardError => e
        # NOP -- allowing ONLY span to notice error
      end
    end

    assert_segment_noticed_error txn, /create|insert/i, expected_error_class, /duplicate key/i

    refute_transaction_noticed_error txn, expected_error_class
  end

  def test_create_via_association_shovel
    in_web_transaction do
      u = User.create(:name => 'thom yorke')
      u.groups << Group.new(:name => 'radiohead')
    end

    assert_activerecord_metrics(Group, 'create')
  end

  def test_create_via_association_create
    in_web_transaction do
      u = User.create(:name => 'thom yorke')
      u.groups.create(:name => 'radiohead')
    end

    assert_activerecord_metrics(Group, 'create')
  end

  def test_create_via_association_create_bang
    in_web_transaction do
      u = User.create(:name => 'thom yorke')
      u.groups.create!(:name => 'radiohead')
    end

    assert_activerecord_metrics(Group, 'create')
  end

  def test_destroy_via_dependent_destroy
    in_web_transaction do
      u = User.create(:name => 'robert')
      u.aliases << Alias.new
      u.destroy
    end

    if active_record_version_at_least?(7)
      assert_activerecord_metrics(User, 'destroy')
      assert_activerecord_metrics(Alias, 'destroy')
    else
      assert_activerecord_metrics(User, 'delete')
      assert_activerecord_metrics(Alias, 'delete')
    end
  end

  def test_metrics_for_update
    in_web_transaction do
      order = Order.create(:name => 'wendy')
      order.update(:name => 'walter')
    end

    assert_activerecord_metrics(Order, 'update')
  end

  def test_metrics_for_update_bang
    in_web_transaction do
      order = Order.create(:name => 'wendy')
      order.update!(:name => 'walter')
    end

    assert_activerecord_metrics(Order, 'update')
  end

  def test_metrics_for_update_attribute
    in_web_transaction do
      order = Order.create(:name => 'wendy')
      order.update_attribute(:name, 'walter')
    end

    assert_activerecord_metrics(Order, 'update')
  end

  def test_metrics_for_save
    in_web_transaction do
      order = Order.create(:name => 'wendy')
      order.name = 'walter'
      order.save
    end

    assert_activerecord_metrics(Order, 'update')
  end

  def test_metrics_for_save_bang
    in_web_transaction do
      order = Order.create(:name => 'wendy')
      order.name = 'walter'
      order.save!
    end

    assert_activerecord_metrics(Order, 'update')
  end

  def test_nested_metrics_dont_get_model_name
    in_web_transaction do
      order = Order.create(:name => 'wendy')
      order.name = 'walter'
      order.save!
    end

    assert_metrics_recorded(['Datastore/operation/Memcached/get'])
    refute_metrics_match(/Memcached.*Order/)
  end

  def test_metrics_for_destroy
    in_web_transaction do
      order = Order.create('name' => 'burt')
      order.destroy
    end

    if active_record_version_at_least?(7)
      assert_activerecord_metrics(Order, 'destroy')
    else
      assert_activerecord_metrics(Order, 'delete')
    end
  end

  def test_metrics_for_direct_sql_select
    in_web_transaction do
      conn = Order.connection
      conn.select_rows("SELECT * FROM #{Order.table_name}")
    end

    assert_generic_rollup_metrics('select')
  end

  def test_metrics_for_direct_sql_other
    in_web_transaction do
      conn = Order.connection
      conn.execute('begin')
      conn.execute('commit')
    end

    assert_generic_rollup_metrics('other')
  end

  def test_metrics_for_direct_sql_show
    skip "Adapter does not support 'show tables'" unless supports_show_tables?

    in_web_transaction do
      Order.with_connection do |conn|
        conn.execute('show tables')
      end
    end

    assert_generic_rollup_metrics('show')
  end

  def test_still_records_metrics_in_error_cases
    # Let's trigger an active record SQL StatementInvalid error
    assert_raises ::ActiveRecord::StatementInvalid do
      in_web_transaction do
        Order.connection.select_rows('select * from askdjfhkajsdhflkjh')
      end
    end

    assert_generic_rollup_metrics('select')
  end

  def test_passes_through_errors
    to_raise = ActiveRecord::ActiveRecordError.new('preserve-me!')
    actually_raised = assert_raises ActiveRecord::ActiveRecordError do
      Order.transaction do
        raise to_raise
      end
    end

    assert_same to_raise, actually_raised
  end

  def test_only_supportability_metrics_recorded_with_disable_all_tracing
    NewRelic::Agent.disable_all_tracing do
      in_web_transaction('bogosity') do
        Order.first
      end
    end

    refute last_transaction_trace
    assert_metrics_recorded_exclusive([
      'Supportability/API/disable_all_tracing',
      'Supportability/API/drop_buffered_data'
    ])
  end

  def test_records_transaction_trace_nodes
    in_web_transaction do
      Order.first
    end
    sample = last_transaction_trace
    metric = "Datastore/statement/#{current_product}/Order/find"
    node = find_node_with_name(sample, metric)

    assert_equal(metric, node.metric_name)

    statement = node.params[:sql]

    assert_match(/^SELECT /, statement.sql)

    assert_match(statement.adapter.to_s, adapter.to_s)
    refute_nil(statement.config)
    refute_nil(statement.explainer)
  end

  def test_gathers_explain_plans
    with_config(:'transaction_tracer.explain_threshold' => -0.1) do
      in_web_transaction do
        Order.first
      end

      sample = last_transaction_trace
      metric = "Datastore/statement/#{current_product}/Order/find"
      sql_node = find_node_with_name(sample, metric)

      assert_match(/^SELECT /, sql_node.params[:sql].sql)

      sample.prepare_to_send!
      explanations = sql_node.params[:explain_plan]

      refute_nil explanations, "No explains in node: #{sql_node}"
      assert_equal(2, explanations.size,
        "No explains in node: #{sql_node}")
    end
  end

  def test_sql_samplers_get_proper_metrics
    with_config(:'transaction_tracer.explain_threshold' => -0.1) do
      in_web_transaction do
        Order.first
      end

      metric = "Datastore/statement/#{current_product}/Order/find"

      refute_nil find_sql_trace(metric)
    end
  end

  def test_records_metrics_on_background_transaction
    in_transaction('back it up') do
      Order.create(:name => 'bob')
    end

    assert_metrics_recorded(['Datastore/all', 'Datastore/allOther'])
    assert_metrics_not_recorded(['Datastore/allWeb'])
  end

  def test_cached_calls_are_not_recorded_with_find
    in_web_transaction do
      order = Order.create(:name => 'Oberon')
      Order.connection.cache do
        Order.find(order.id)
        Order.find(order.id)
        Order.find(order.id)
      end
    end

    assert_activerecord_metrics(Order, 'find', :call_count => 1)
  end

  def test_cached_calls_are_not_recorded_with_select_all
    # If this is the first create, ActiveRecord needs to warm up,
    # send some SQL SELECTS, etc.
    in_web_transaction do
      Order.create(:name => 'Oberon')
    end
    NewRelic::Agent.drop_buffered_data

    # the actual test is here
    query = "SELECT * FROM #{Order.table_name} WHERE name = 'Oberon'"
    in_web_transaction do
      Order.connection.cache do
        Order.connection.select_all(query)
        Order.connection.select_all(query)
        Order.connection.select_all(query)
      end
    end

    assert_metrics_recorded(
      {"Datastore/operation/#{current_product}/select" => {:call_count => 1}}
    )
  end

  def test_with_database_metric_name
    in_web_transaction do
      Order.create(:name => 'eely')
      NewRelic::Agent.with_database_metric_name('Eel', 'squirm') do
        Order.connection.select_rows("SELECT id FROM #{Order.table_name}")
      end
    end

    assert_metrics_recorded(
      {
        "Datastore/statement/#{current_product}/Eel/squirm" => {:call_count => 1},
        "Datastore/operation/#{current_product}/squirm" => {:call_count => 1}
      }
    )
  end

  def test_metrics_for_async_find_by_sql
    skip unless active_record_version_at_least?(7.1)

    in_web_transaction do
      Order.async_find_by_sql('SELECT * FROM orders')
    end

    assert_activerecord_metrics(Order, 'find')
  end

  def test_metrics_for_async_count_by_sql
    skip unless active_record_version_at_least?(7.1)

    in_web_transaction do
      Order.create(:name => 'wendy')
      Order.count_by_sql("SELECT * FROM orders where name = 'wendy'")
    end

    assert_activerecord_metrics(Order, 'find')
  end

  def test_metrics_for_async_pluck
    skip unless active_record_version_at_least?(7.1)

    in_web_transaction do
      Order.async_pluck(:id)
    end

    assert_activerecord_metrics(Order, 'pluck')
  end

  def test_metrics_for_async_calculation_methods
    skip unless active_record_version_at_least?(7.1)

    in_web_transaction do
      Order.async_count
      Order.async_average(:id)
      Order.async_minimum(:id)
      Order.async_maximum(:id)
      Order.async_sum(:id)
    end

    assert_activerecord_metrics(Order, 'find')
  end

  ## helpers
  private

  def adapter
    # ActiveRecord::Base.configurations[NewRelic::Control.instance.env]['adapter']
    #       ActiveRecord::Base.configs_for(env_name: NewRelic::Control.instance.env)['adapter']
    #       ActiveRecord::Base.configs_for(env_name: RAILS_ENV)['adapter']
    # Order.configurations[RAILS_ENV]['adapter']
    adapter_string = ::NewRelic::Agent::DatabaseAdapter.value
    adapter_string.downcase.to_sym
  end

  def supports_show_tables?
    [:mysql, :mysql2, :trilogy].include?(adapter)
  end

  def current_product
    NewRelic::Agent::Instrumentation::ActiveRecordHelper::PRODUCT_NAMES[adapter.to_s]
  end

  def operation_for(op)
    if op == 'create'
      active_record_version_at_least?(5.2) ? 'create' : 'insert'
    elsif op == 'delete' && !active_record_version_at_least?(7)
      active_record_version_at_least?(5.2) ? 'destroy' : 'delete'
    else
      op
    end
  end

  def assert_activerecord_metrics(model, operation, stats = {})
    operation = operation_for(operation) if %w[create delete].include?(operation)

    assert_metrics_recorded({
      "Datastore/statement/#{current_product}/#{model}/#{operation}" => stats,
      "Datastore/operation/#{current_product}/#{operation}" => {},
      'Datastore/allWeb' => {},
      'Datastore/all' => {}
    })
  end

  def assert_generic_rollup_metrics(operation)
    assert_metrics_recorded([
      "Datastore/operation/#{current_product}/#{operation}",
      'Datastore/allWeb',
      'Datastore/all'
    ])
  end

  def active_record_version
    Gem::Version.new(::ActiveRecord::VERSION::STRING)
  end

  def active_record_version_at_least?(version_string)
    version_string = version_string.to_s
    version_string += '.0' until version_string.count('.') >= 2 # '7' => '7.0.0'
    active_record_version >= Gem::Version.new(version_string)
  end
end
